Angular Signals landed as stable in Angular 17 and have matured significantly through Angular 21. The community reaction has been polarized: some developers want to replace every Observable with a signal, while others cling to RxJS for everything. Both extremes are wrong.

This guide explains what signals actually are under the hood, when they genuinely replace RxJS, and when observables remain the better tool.

What Signals Actually Are

A signal is a reactive primitive that holds a value and notifies consumers when that value changes. Unlike observables, signals are synchronous, always have a current value, and are pull-based (consumers read the value when they need it).

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

// Create a writable signal
const count = signal(0);

// Read the value (call it like a function)
console.log(count()); // 0

// Update the value
count.set(5);
count.update(prev => prev + 1); // 6

// Computed: derived value that auto-updates
const doubled = computed(() => count() * 2);
console.log(doubled()); // 12

// Effect: run side effects when dependencies change
effect(() => {
  console.log(`Count changed to ${count()}`);
});

Signals vs Observables: The Core Difference

Aspect Signal Observable (RxJS)
Execution Synchronous Can be async
Current Value Always has one May not (streams)
Model Pull-based Push-based
Glitch-Free Yes (batched updates) No (each emission triggers independently)
Cleanup Automatic (injection context) Manual (unsubscribe)
Operators computed() only 100+ operators (map, filter, merge, debounce...)
Best For Component state, UI bindings Async events, HTTP, WebSocket, complex streams

When to Use Signals

  • Component local state: Form values, toggle flags, counters, selected items
  • Derived/computed values: Filtered lists, formatted strings, validation states
  • Input/output binding: Component inputs and template bindings
  • Simple shared state: Service-level state that multiple components read

Before (RxJS for simple state)

// Old pattern: BehaviorSubject for simple toggle
@Injectable({ providedIn: 'root' })
export class SidebarService {
  private isOpen$ = new BehaviorSubject<boolean>(false);
  isOpen$ = this.isOpen$.asObservable();

  toggle() {
    this.isOpen$.next(!this.isOpen$.value);
  }
}

// Component (needs async pipe or manual subscribe)
@Component({
  template: `<aside *ngIf="isOpen$ | async">...</aside>`
})
export class SidebarComponent {
  isOpen$ = inject(SidebarService).isOpen$;
}

After (Signal — simpler, no subscription management)

@Injectable({ providedIn: 'root' })
export class SidebarService {
  isOpen = signal(false);

  toggle() {
    this.isOpen.update(open => !open);
  }
}

@Component({
  template: `@if (isOpen()) { <aside>...</aside> }`
})
export class SidebarComponent {
  isOpen = inject(SidebarService).isOpen;
}

When to Keep RxJS

  • HTTP requests: HttpClient returns observables with retry, timeout, and cancellation
  • Debounced search: debounceTime + switchMap is irreplaceable for typeahead
  • WebSocket streams: Continuous push-based data flow
  • Complex event composition: merge, combineLatest, race, forkJoin
  • Route events: Router events are observable-based
// RxJS is STILL better for debounced search
@Component({
  template: `<input [formControl]="searchControl">`
})
export class SearchComponent {
  searchControl = new FormControl('');
  results = signal<Result[]>([]);

  constructor() {
    // RxJS for the stream, signal for the result
    this.searchControl.valueChanges.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(query => this.searchService.search(query)),
      takeUntilDestroyed()
    ).subscribe(results => {
      this.results.set(results);  // Bridge to signal
    });
  }
}

Bridging Signals and Observables

Angular provides built-in functions to convert between signals and observables:

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

// Observable to Signal
const data = toSignal(this.http.get<Data[]>('/api/data'), {
  initialValue: []  // Required: signals must always have a value
});

// Signal to Observable
const count = signal(0);
const count$ = toObservable(count);
count$.pipe(
  debounceTime(500)
).subscribe(val => console.log(val));

Computed Signals: Gotchas

1. Computed signals are lazy and cached

const items = signal([1, 2, 3, 4, 5]);
const total = computed(() => {
  console.log('Computing total...'); // Only runs when read AND deps changed
  return items().reduce((a, b) => a + b, 0);
});

// First read: computes and caches
console.log(total()); // "Computing total..." then 15

// Second read without change: returns cached value
console.log(total()); // 15 (no recomputation!)

2. Do not mutate objects inside signals

const user = signal({ name: 'Alice', age: 30 });

// WRONG: mutation is not detected
user().name = 'Bob'; // Signal does not know it changed!

// RIGHT: create a new reference
user.update(u => ({ ...u, name: 'Bob' }));

3. Effect cleanup and injection context

// Effects must run in an injection context (constructor, field initializer)
@Component({...})
export class MyComponent {
  count = signal(0);

  // Works: field initializer is in injection context
  logger = effect(() => {
    console.log(`Count: ${this.count()}`);
  });

  // WRONG: ngOnInit is NOT an injection context
  ngOnInit() {
    // effect(() => {}); // Error!
  }
}

Signal-Based Components Pattern

@Component({
  selector: 'app-product-list',
  template: `
    <input type="text" (input)="filterText.set(input.value)" placeholder="Search...">
    <select (change)="sortBy.set(select.value)">
      <option value="name">Name</option>
      <option value="price">Price</option>
    </select>
    <p>Showing {{ filteredProducts().length }} of {{ products().length }}</p>
    @for (product of filteredProducts(); track product.id) {
      <app-product-card [product]="product" />
    }
  `
})
export class ProductListComponent {
  private productService = inject(ProductService);

  products = toSignal(this.productService.getAll(), { initialValue: [] });
  filterText = signal('');
  sortBy = signal('name');

  filteredProducts = computed(() => {
    const text = this.filterText().toLowerCase();
    const sort = this.sortBy();

    return this.products()
      .filter(p => p.name.toLowerCase().includes(text))
      .sort((a, b) => a[sort] > b[sort] ? 1 : -1);
  });
}

This component has zero subscriptions, zero OnDestroy cleanup, and zero async pipes. The template reads signals directly, and Angular only re-renders when the computed value actually changes.

Migration Strategy

  1. New code: Write all new components with signals by default
  2. Simple state: Replace BehaviorSubject services with signal services
  3. Keep RxJS for: HTTP, WebSocket, debounce, complex stream composition
  4. Bridge pattern: Use toSignal() at the component level to consume observables as signals
  5. Do not force it: If RxJS is cleaner for a specific case, keep it. Signals and observables coexist.

Key Takeaways

  • Signals replace BehaviorSubject for synchronous state — simpler, no subscriptions to manage
  • RxJS remains essential for async streams — HTTP, WebSocket, debounce, complex event composition
  • Use computed() for derived values — it is lazy, cached, and glitch-free
  • Never mutate signal values directly — always create new references with update() or set()
  • Bridge with toSignal() and toObservable() — use the right primitive at each layer
  • Effects run in injection context only — field initializers or constructors, not lifecycle hooks
  • Signals and RxJS coexist — this is not a replacement, it is an addition to your toolkit

The best Angular code in 2026 uses both signals and observables, each where they are strongest. Signals for component state and UI bindings. Observables for async operations and complex event streams. Do not pick a side — use the right tool for each job.