Migrating from Angular RxJS to Signals: A Professional Guide

Angular Signals represent a paradigm shift in how we handle reactive data in Angular applications. Introduced in Angular 16, Signals provide a more intuitive way to manage state changes while maintaining the power of reactive programming.

This guide will help you migrate from traditional RxJS patterns to Signals, with practical examples and best practices.

What Are Signals?

Signals are reactive primitives that track dependencies and automatically update when values change. They offer several advantages over traditional RxJS observables:

  • Zero learning curve for simple reactive patterns
  • Better performance with automatic dependency tracking
  • Cleaner syntax for common reactive scenarios
  • Type safety with TypeScript integration
  • Zone.js coexistence without additional complexity

Migration Strategy: Before and After

1. Simple Component State

Before (RxJS with BehaviorSubject):

import { Component, OnDestroy } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Component({
  selector: 'app-user-profile',
  template: `
    <div *ngIf="user$ | async as user">
      <h2></h2>
      <p></p>
    </div>
    <button (click)="updateUser()">Update User</button>
  `
})
export class UserProfileComponent implements OnDestroy {
  private destroy$ = new Subject<void>();
  user$ = new BehaviorSubject<User | null>(null);

  constructor() {
    this.user$.pipe(takeUntil(this.destroy$)).subscribe(user => {
      console.log('User changed:', user);
    });
  }

  updateUser() {
    const currentUser = this.user$.value;
    if (currentUser) {
      this.user$.next({ ...currentUser, name: 'Updated Name' });
    }
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

After (Signals):

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

@Component({
  selector: 'app-user-profile',
  template: `
    <div *ngIf="user(); as user">
      <h2></h2>
      <p></p>
    </div>
    <button (click)="updateUser()">Update User</button>
  `
})
export class UserProfileComponent {
  user = signal<User | null>(null);
  userDisplayName = computed(() => this.user()?.name || 'Unknown User');

  constructor() {
    effect(() => {
      console.log('User changed:', this.user());
    });
  }

  updateUser() {
    this.user.update(currentUser => 
      currentUser ? { ...currentUser, name: 'Updated Name' } : null
    );
  }
}

2. Computed Values

Before (RxJS with combineLatest):

import { Component } from '@angular/core';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';

@Component({
  selector: 'app-price-calculator',
  template: `
    <div *ngIf="totalPrice$ | async as price">
      <p>Base: ${{ basePrice$ | async }}</p>
      <p>Tax: ${{ tax$ | async }}</p>
      <p>Total: ${{ price }}</p>
    </div>
  `
})
export class PriceCalculatorComponent {
  basePrice$ = new BehaviorSubject<number>(100);
  taxRate$ = new BehaviorSubject<number>(0.1);
  tax$ = this.basePrice$.pipe(
    map(price => price * this.taxRate$.value)
  );
  totalPrice$ = combineLatest([
    this.basePrice$,
    this.tax$
  ]).pipe(
    map(([base, tax]) => base + tax)
  );
}

After (Computed Signals):

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

@Component({
  selector: 'app-price-calculator',
  template: `
    <div>
      <p>Base: ${{ basePrice() }}</p>
      <p>Tax: ${{ tax() }}</p>
      <p>Total: ${{ totalPrice() }}</p>
    </div>
  `
})
export class PriceCalculatorComponent {
  basePrice = signal<number>(100);
  taxRate = signal<number>(0.1);
  
  tax = computed(() => this.basePrice() * this.taxRate());
  totalPrice = computed(() => this.basePrice() + this.tax());
}

3. Form Handling

Before (FormBuilder with valueChanges):

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

@Component({
  selector: 'app-search-form',
  template: `
    <form [formGroup]="searchForm">
      <input formControlName="query" placeholder="Search...">
      <select formControlName="category">
        <option value="">All</option>
        <option value="products">Products</option>
        <option value="users">Users</option>
      </select>
    </form>
    <div *ngIf="searchResults$ | async as results">
      <p>Found  results</p>
    </div>
  `
})
export class SearchFormComponent implements OnInit {
  searchForm: FormGroup;
  searchResults$ = this.searchForm.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(values => this.searchService.search(values))
  );

  constructor(private fb: FormBuilder, private searchService: SearchService) {
    this.searchForm = this.fb.group({
      query: [''],
      category: ['']
    });
  }

  ngOnInit() {
    this.searchForm.patchValue({ query: 'initial' });
  }
}

After (Signals with FormControlSignal):

import { Component, computed, signal } from '@angular/core';
import { FormControl } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-search-form',
  template: `
    <form>
      <input [formControl]="queryControl" placeholder="Search...">
      <select [formControl]="categoryControl">
        <option value="">All</option>
        <option value="products">Products</option>
        <option value="users">Users</option>
      </select>
    </form>
    <div *ngIf="searchResults(); as results">
      <p>Found  results</p>
    </div>
  `
})
export class SearchFormComponent {
  queryControl = new FormControl('');
  categoryControl = new FormControl('');
  
  // Convert FormControl to Signal
  querySignal = toSignal(this.queryControl.valueChanges, { initialValue: '' });
  categorySignal = toSignal(this.categoryControl.valueChanges, { initialValue: '' });
  
  searchResults = signal<SearchResult[]>([]);
  
  // Computed search parameters
  searchParams = computed(() => ({
    query: this.querySignal(),
    category: this.categorySignal()
  }));

  constructor(private searchService: SearchService) {
    // Effect to trigger search when parameters change
    effect(() => {
      const params = this.searchParams();
      if (params.query) {
        this.performSearch(params);
      }
    });
  }

  private performSearch(params: SearchParams) {
    // Debounce logic would need custom implementation
    // or use toSignal with debounce operator
    this.searchService.search(params).subscribe(results => {
      this.searchResults.set(results);
    });
  }
}

Step-by-Step Migration Process

Step 1: Identify Candidates for Migration

Start with simple state management scenarios:

  • Component-level state (no complex async operations)
  • Derived/computed values
  • Form handling without complex validation
  • Simple data transformations

Step 2: Install Dependencies

Ensure you have Angular 16+ with Signals support:

ng update @angular/core @angular/cli

Step 3: Gradual Migration Approach

Use toSignal to bridge RxJS and Signals:

import { toSignal } from '@angular/core/rxjs-interop';

// Keep existing RxJS but convert to Signal when needed
existingObservable$ = this.http.get('/api/data');
dataSignal = toSignal(this.existingObservable$, { initialValue: null });

Step 4: Replace BehaviorSubject with Signal

// Old
private data$ = new BehaviorSubject<T>(null);
public data$ = this.data$.asObservable();

// New
private data = signal<T>(null);
public data = this.data.asReadonly();

Step 5: Convert Computed Values

// Old
computedValue$ = combineLatest([source1$, source2$]).pipe(
  map(([a, b]) => a + b)
);

// New
computedValue = computed(() => source1() + source2());

Best Practices

1. Use Signals for Component State

@Component({ /* ... */ })
export class MyComponent {
  // Good: Simple component state
  isLoading = signal(false);
  error = signal<string | null>(null);
  
  // Good: Computed derived state
  canSubmit = computed(() => !this.isLoading() && !this.error());
}

2. Keep RxJS for Complex Async Operations

@Component({ /* ... */ })
export class MyComponent {
  // Keep RxJS for complex async patterns
  searchResults$ = this.searchTerms$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(term => this.api.search(term))
  );
  
  // Convert to Signal when needed
  searchResults = toSignal(this.searchResults$, { initialValue: [] });
}

3. Use Computed for Derived Values

// Good: Computed values are cached and efficient
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
isAdult = computed(() => this.age() >= 18);

4. Effects for Side Effects

@Component({ /* ... */ })
export class MyComponent {
  constructor() {
    effect(() => {
      // Side effects when data changes
      const user = this.currentUser();
      if (user) {
        this.analytics.trackUserView(user.id);
      }
    });
  }
}

When to Stick with RxJS

Signals don’t replace RxJS entirely. Keep RxJS for:

  • Complex async operations with multiple operators
  • HTTP request cancellation scenarios
  • Time-based operations (intervals, timeouts)
  • Complex error handling and retry logic
  • Event handling from DOM or external sources

Performance Considerations

Signal Benefits

  • Fine-grained reactivity: Only affected components update
  • Automatic dependency tracking: No manual subscription management
  • Memory efficiency: No subscription cleanup needed
  • Tree-shakable: Unused code is eliminated

RxJS Strengths

  • Mature ecosystem: Vast operator library
  • Backpressure handling: Built-in control mechanisms
  • Complex async patterns: Powerful composition capabilities

Migration Checklist

  • Identify simple state management patterns
  • Install Angular 16+ with Signals support
  • Convert BehaviorSubject to Signal
  • Replace combineLatest with computed
  • Use toSignal for gradual migration
  • Update templates to use signal() instead of async pipe
  • Test performance improvements
  • Keep RxJS for complex scenarios

Conclusion

Signals provide a cleaner, more intuitive way to handle reactive state in Angular applications. They complement rather than replace RxJS, giving developers the right tool for each scenario.

Start with simple state management and gradually expand your use of Signals. The combination of Signals for simple reactivity and RxJS for complex async patterns gives you the best of both worlds.

Remember: The goal isn’t to eliminate RxJS entirely, but to use the right tool for each job. Signals excel at component state and derived values, while RxJS remains powerful for complex async operations.

Reference

https://angular.dev/reference/migrations