Skip to main content

Overview

Signals are a reactive primitive that provide fine-grained reactivity for managing state in Angular applications. They enable automatic dependency tracking and efficient change detection.

Core Concepts

Writable Signals

Create signals using the signal() function to hold reactive values.
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <div class="counter">
      <h2>Count: {{ count() }}</h2>
      <button (click)="increment()">+</button>
      <button (click)="decrement()">-</button>
      <button (click)="reset()">Reset</button>
    </div>
  `
})
export class CounterComponent {
  // Create a writable signal with initial value
  count = signal(0);

  increment(): void {
    // Update signal value
    this.count.update(value => value + 1);
  }

  decrement(): void {
    this.count.update(value => value - 1);
  }

  reset(): void {
    // Set absolute value
    this.count.set(0);
  }
}

Reading Signal Values

Access signal values by calling them as functions.
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-user-profile',
  template: `
    <div>
      <h2>{{ username() }}</h2>
      <p>Score: {{ score() }}</p>
    </div>
  `
})
export class UserProfileComponent {
  username = signal('John Doe');
  score = signal(0);

  constructor() {
    // Read signal values in TypeScript
    console.log('Current username:', this.username());
    console.log('Current score:', this.score());
  }
}

Computed Signals

Create derived values that automatically update when dependencies change.
import { Component, signal, computed } from '@angular/core';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

@Component({
  selector: 'app-shopping-cart',
  template: `
    <div class="cart">
      <h2>Shopping Cart</h2>
      <div *ngFor="let item of items()">
        <span>{{ item.name }} x {{ item.quantity }}</span>
        <span>\${{ item.price * item.quantity }}</span>
      </div>
      <hr>
      <p>Total Items: {{ totalItems() }}</p>
      <p>Subtotal: \${{ subtotal() }}</p>
      <p>Tax (8%): \${{ tax() }}</p>
      <p><strong>Total: \${{ total() }}</strong></p>
    </div>
  `
})
export class ShoppingCartComponent {
  items = signal<CartItem[]>([
    { id: 1, name: 'Laptop', price: 999.99, quantity: 1 },
    { id: 2, name: 'Mouse', price: 29.99, quantity: 2 }
  ]);

  // Computed signals automatically track dependencies
  totalItems = computed(() => {
    return this.items().reduce((sum, item) => sum + item.quantity, 0);
  });

  subtotal = computed(() => {
    return this.items().reduce(
      (sum, item) => sum + (item.price * item.quantity), 
      0
    );
  });

  tax = computed(() => this.subtotal() * 0.08);

  total = computed(() => this.subtotal() + this.tax());

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

  updateQuantity(id: number, quantity: number): void {
    this.items.update(current =>
      current.map(item => 
        item.id === id ? { ...item, quantity } : item
      )
    );
  }
}
Computed signals are read-only and automatically recompute only when their dependencies change, making them highly efficient.

Effects

Execute side effects when signals change using the effect() function.
import { Component, signal, effect } from '@angular/core';
import { AnalyticsService } from './analytics.service';

@Component({
  selector: 'app-search',
  template: `
    <input 
      [value]="searchQuery()"
      (input)="searchQuery.set($any($event.target).value)"
      placeholder="Search..."
    >
    <p>Searching for: {{ searchQuery() }}</p>
  `
})
export class SearchComponent {
  searchQuery = signal('');

  constructor(private analytics: AnalyticsService) {
    // Effect runs whenever searchQuery changes
    effect(() => {
      const query = this.searchQuery();
      if (query.length > 2) {
        console.log('Searching for:', query);
        this.analytics.trackSearch(query);
      }
    });
  }
}

Effect Cleanup

Register cleanup functions to handle resource disposal.
import { Component, signal, effect } from '@angular/core';

@Component({
  selector: 'app-auto-save',
  template: `
    <textarea 
      [value]="content()"
      (input)="content.set($any($event.target).value)"
    ></textarea>
    <p>{{ saveStatus() }}</p>
  `
})
export class AutoSaveComponent {
  content = signal('');
  saveStatus = signal('All changes saved');

  constructor() {
    effect((onCleanup) => {
      const currentContent = this.content();
      this.saveStatus.set('Saving...');

      // Set up auto-save timer
      const timerId = setTimeout(() => {
        this.saveToServer(currentContent);
        this.saveStatus.set('All changes saved');
      }, 1000);

      // Cleanup function cancels pending save
      onCleanup(() => {
        clearTimeout(timerId);
      });
    });
  }

  private saveToServer(content: string): void {
    console.log('Saving:', content);
  }
}

Advanced Patterns

Signal-based State Management

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

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
  createdAt: Date;
}

export type TodoFilter = 'all' | 'active' | 'completed';

@Injectable({ providedIn: 'root' })
export class TodoStore {
  // Private state
  private todos = signal<Todo[]>([]);
  private filter = signal<TodoFilter>('all');

  // Public computed selectors
  readonly allTodos = this.todos.asReadonly();
  
  readonly filteredTodos = computed(() => {
    const todos = this.todos();
    const currentFilter = this.filter();

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

  readonly stats = computed(() => {
    const todos = this.todos();
    return {
      total: todos.length,
      active: todos.filter(t => !t.completed).length,
      completed: todos.filter(t => t.completed).length
    };
  });

  readonly currentFilter = this.filter.asReadonly();

  // Actions
  addTodo(title: string): void {
    const newTodo: Todo = {
      id: Date.now(),
      title,
      completed: false,
      createdAt: new Date()
    };
    this.todos.update(todos => [...todos, newTodo]);
  }

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

  removeTodo(id: number): void {
    this.todos.update(todos => todos.filter(t => t.id !== id));
  }

  setFilter(filter: TodoFilter): void {
    this.filter.set(filter);
  }

  clearCompleted(): void {
    this.todos.update(todos => todos.filter(t => !t.completed));
  }
}

Using the Store

import { Component } from '@angular/core';
import { TodoStore, TodoFilter } from './todo.store';

@Component({
  selector: 'app-todos',
  template: `
    <div class="todo-app">
      <input 
        #input
        (keyup.enter)="addTodo(input.value); input.value = ''"
        placeholder="What needs to be done?"
      >
      
      <div class="filters">
        <button (click)="setFilter('all')">All ({{ store.stats().total }})</button>
        <button (click)="setFilter('active')">Active ({{ store.stats().active }})</button>
        <button (click)="setFilter('completed')">Completed ({{ store.stats().completed }})</button>
      </div>

      <ul>
        <li *ngFor="let todo of store.filteredTodos()">
          <input 
            type="checkbox" 
            [checked]="todo.completed"
            (change)="store.toggleTodo(todo.id)"
          >
          <span [class.completed]="todo.completed">{{ todo.title }}</span>
          <button (click)="store.removeTodo(todo.id)">Delete</button>
        </li>
      </ul>

      <button 
        *ngIf="store.stats().completed > 0"
        (click)="store.clearCompleted()"
      >
        Clear Completed
      </button>
    </div>
  `
})
export class TodosComponent {
  constructor(public store: TodoStore) {}

  addTodo(title: string): void {
    if (title.trim()) {
      this.store.addTodo(title.trim());
    }
  }

  setFilter(filter: TodoFilter): void {
    this.store.setFilter(filter);
  }
}

RxJS Interop

Convert between signals and observables for integration with existing RxJS code.
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';

interface SearchResult {
  id: number;
  title: string;
}

@Component({
  selector: 'app-search-rxjs',
  template: `
    <input 
      [value]="searchTerm()"
      (input)="searchTerm.set($any($event.target).value)"
    >
    <ul>
      <li *ngFor="let result of results()">{{ result.title }}</li>
    </ul>
    <p *ngIf="isLoading()">Loading...</p>
  `
})
export class SearchRxJSComponent {
  searchTerm = signal('');
  
  // Convert signal to observable
  private searchTerm$ = toObservable(this.searchTerm);

  // Process with RxJS operators
  private results$ = this.searchTerm$.pipe(
    debounceTime(300),
    switchMap(term => 
      term ? this.http.get<SearchResult[]>(`/api/search?q=${term}`) : []
    )
  );

  // Convert observable back to signal
  results = toSignal(this.results$, { initialValue: [] });
  isLoading = signal(false);

  constructor(private http: HttpClient) {}
}

Best Practices

Use Computed for Derived State

Prefer computed() over manually updating multiple signals. It’s more efficient and easier to maintain.

Keep Signals Fine-Grained

Split state into focused signals rather than one large object signal for better granularity.

Effects for Side Effects Only

Use effect() for side effects, not for deriving state. Use computed() for derived values.

Immutable Updates

Use immutable patterns with update() to ensure predictable state changes.
Don’t read signals inside effects without using them. This creates unnecessary dependencies and can cause performance issues.

Performance Benefits

Signals provide significant performance improvements:
  • Fine-grained updates: Only components reading changed signals are updated
  • Automatic dependency tracking: No manual subscription management
  • Efficient computation: Computed values only recalculate when dependencies change
  • Works with OnPush: Seamlessly integrates with OnPush change detection

Migration from RxJS

// Before: Using RxJS BehaviorSubject
import { BehaviorSubject } from 'rxjs';

export class OldComponent {
  private countSubject = new BehaviorSubject(0);
  count$ = this.countSubject.asObservable();

  increment(): void {
    this.countSubject.next(this.countSubject.value + 1);
  }
}

// After: Using Signals
import { signal } from '@angular/core';

export class NewComponent {
  count = signal(0);

  increment(): void {
    this.count.update(n => n + 1);
  }
}

Additional Resources