Skip to content

Angular Signals: The Future of Reactive Programming

Explore Angular Signals, a new reactive primitive that simplifies state management and improves performance with fine-grained reactivity.

By 6 min read
  • Angular
  • Signals
  • Reactive Programming
  • TypeScript
  • Angular 16+
Angular Signals: The Future of Reactive Programming

Angular Signals, introduced in Angular 16, represent a fundamental shift in how we handle reactivity in Angular applications. After working with Signals in Angular 18 projects like Danone BAPEN and Ship-Watch, I've found them to be a game-changer for performance and developer experience. Signals provide fine-grained reactivity, automatic change detection optimization, and a simpler mental model than RxJS Observables for many use cases.

What are Signals?

Signals are reactive primitives that hold a value and notify consumers when that value changes. They're similar to Observables but simpler for many scenarios, with automatic dependency tracking and optimized change detection.

Basic Signal Usage

Create and use signals:

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

@Component({
  selector: 'app-counter',
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ doubleCount() }}</p>
    <button (click)="increment()">Increment</button>
  `,
})
export class CounterComponent {
  // Writable signal
  count = signal<number>(0);

  // Computed signal (derived state)
  doubleCount = computed(() => this.count() * 2);

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

  // Effect runs when dependencies change
  constructor() {
    effect(() => {
      console.log('Count changed:', this.count());
    });
  }
}

Signal Operations

Creating Signals

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

// Writable signal with initial value
const count = signal<number>(0);

// Signal with object
const user = signal<User>({ id: '1', name: 'John' });

// Read-only signal (computed)
const doubleCount = computed(() => count() * 2);

// Computed with multiple dependencies
const total = computed(() => count() + anotherCount());

Updating Signals

TSX
// Set value directly
count.set(10);

// Update using current value
count.update(value => value + 1);

// Update object signals
user.update(user => ({ ...user, name: 'Jane' }));

// Mutate object (if using mutable pattern)
user.mutate(user => {
  user.name = 'Jane';
});

Signals in Components

Use signals for component state:

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

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

@Component({
  selector: 'app-todo-list',
  template: `
    <input #input (keyup.enter)="addTodo(input.value)" />
    <ul>
      <li *ngFor="let todo of todos()">
        <input 
          type="checkbox" 
          [checked]="todo.done"
          (change)="toggleTodo(todo.id)"
        />
        {{ todo.text }}
      </li>
    </ul>
    <p>Completed: {{ completedCount() }}</p>
  `,
})
export class TodoListComponent {
  todos = signal<Todo[]>([]);

  completedCount = computed(() =>
    this.todos().filter(todo => todo.done).length
  );

  addTodo(text: string): void {
    this.todos.update(todos => [
      ...todos,
      { id: Date.now(), text, done: false }
    ]);
  }

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

Signals with Input/Output

Use signals for component inputs and outputs:

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

@Component({
  selector: 'app-user-card',
  template: `
    <div>
      <h3>{{ user().name }}</h3>
      <p>{{ user().email }}</p>
      <button (click)="onSelect.emit(user().id)">Select</button>
    </div>
  `,
})
export class UserCardComponent {
  // Signal-based input
  user = input.required<User>();

  // Signal-based output
  onSelect = output<string>();

  // Computed from input signal
  displayName = computed(() => this.user().name.toUpperCase());
}

Effects

Effects run side effects when signals change:

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

@Component({
  selector: 'app-search',
  template: `
    <input [(ngModel)]="searchTerm" />
    <div *ngFor="let result of results()">{{ result }}</div>
  `,
})
export class SearchComponent {
  searchTerm = signal<string>('');
  results = signal<string[]>([]);

  constructor(private searchService: SearchService) {
    // Effect runs when searchTerm changes
    effect(() => {
      const term = this.searchTerm();
      if (term.length > 2) {
        this.searchService.search(term).subscribe(results => {
          this.results.set(results);
        });
      } else {
        this.results.set([]);
      }
    });
  }
}

Signals vs Observables

When to use each:

TSX
// Use Signals for:
// - Component state
// - Derived/computed values
// - Simple reactive values

const count = signal(0);
const double = computed(() => count() * 2);

// Use Observables for:
// - Async operations (HTTP, WebSockets)
// - Complex event streams
// - When you need operators (map, filter, etc.)

this.userService.getUsers().pipe(
  map(users => users.filter(u => u.active)),
  debounceTime(300)
).subscribe(activeUsers => {
  // Handle async data
});

Combining Signals and Observables

Convert between signals and observables:

TSX
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { Component } from '@angular/core';
import { UserService } from './user.service';

@Component({
  selector: 'app-users',
  template: `
    <div *ngFor="let user of users()">{{ user.name }}</div>
  `,
})
export class UsersComponent {
  // Convert Observable to Signal
  users = toSignal(this.userService.getUsers(), {
    initialValue: []
  });

  // Convert Signal to Observable
  count$ = toObservable(this.countSignal);
}

Signal-Based Forms

Use signals with reactive forms:

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

@Component({
  selector: 'app-user-form',
  template: `
    <form [formGroup]="form">
      <input formControlName="name" />
      <input formControlName="email" />
      <button [disabled]="!isValid()">Submit</button>
    </form>
  `,
})
export class UserFormComponent {
  form: FormGroup;
  
  isValid = computed(() => this.form.valid);

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      name: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
    });
  }
}

Performance Benefits

Signals provide automatic optimization:

TSX
@Component({
  selector: 'app-product-list',
  template: `
    <div *ngFor="let product of filteredProducts()">
      {{ product.name }}
    </div>
  `,
})
export class ProductListComponent {
  products = signal<Product[]>([]);
  filter = signal<string>('');

  // Only recomputes when products or filter change
  filteredProducts = computed(() => {
    const filterValue = this.filter().toLowerCase();
    return this.products().filter(product =>
      product.name.toLowerCase().includes(filterValue)
    );
  });
}

Best Practices

  1. Use signals for component state: Replace local variables with signals
  2. Use computed for derived state: Automatic memoization
  3. Combine with Observables: Use toSignal and toObservable
  4. Use effects sparingly: Prefer computed signals when possible
  5. Signal-based inputs: Use input() for better type safety
  6. Avoid nested signals: Keep signal structure flat when possible
  7. Use with OnPush: Signals work excellently with OnPush change detection

Real-World Example

Complete example from production:

TSX
import { Component, signal, computed, effect } from '@angular/core';
import { PlayerService } from './player.service';

interface Player {
  id: string;
  name: string;
  position: string;
  active: boolean;
}

@Component({
  selector: 'app-player-list',
  template: `
    <input 
      [(ngModel)]="searchTerm"
      (ngModelChange)="searchTerm.set($event)"
      placeholder="Search players"
    />
    <select 
      [(ngModel)]="selectedPosition"
      (ngModelChange)="selectedPosition.set($event)"
    >
      <option value="">All Positions</option>
      <option value="forward">Forward</option>
      <option value="midfielder">Midfielder</option>
    </select>
    <div *ngFor="let player of filteredPlayers()">
      {{ player.name }} - {{ player.position }}
    </div>
    <p>Total: {{ filteredPlayers().length }}</p>
  `,
})
export class PlayerListComponent {
  players = signal<Player[]>([]);
  searchTerm = signal<string>('');
  selectedPosition = signal<string>('');

  filteredPlayers = computed(() => {
    const term = this.searchTerm().toLowerCase();
    const position = this.selectedPosition();
    
    return this.players().filter(player => {
      const matchesSearch = player.name.toLowerCase().includes(term);
      const matchesPosition = !position || player.position === position;
      return matchesSearch && matchesPosition;
    });
  });

  constructor(private playerService: PlayerService) {
    effect(() => {
      this.playerService.getPlayers().subscribe(players => {
        this.players.set(players);
      });
    });
  }
}

Performance Impact

Signals provide:

  • Fine-grained reactivity (only affected components update)
  • Automatic change detection optimization
  • Better performance with OnPush strategy
  • Reduced memory overhead compared to Observables
  • Simpler mental model for many use cases

Angular Signals represent the future of reactivity in Angular. They complement Observables perfectly, providing a simpler API for many common scenarios while maintaining the power of RxJS for complex async operations.

Keep reading