Skip to main content

Angular Signals vs RxJS: Determining the Right Architecture for Async State

 

The Trap: Signal-Only Dogmatism

The introduction of Signals in Angular led to a predictable over-correction: developers are attempting to purge RxJS entirely from their codebases. The friction point is almost always asynchronous data fetching based on user input (e.g., search type-aheads, dependent dropdowns, or paginated lists).

In the RxJS era, switchMap was the definitive solution for handling race conditions. It automatically cancelled pending requests when new emissions occurred.

When developers attempt to port this logic purely to Signals using computed or effect, they encounter a critical architectural wall. A computed signal cannot be asynchronous, and an effect should rarely write to other signals. Consequently, we see code like this entering production:

// ❌ ANTI-PATTERN: The "Manual SwitchMap"
effect(async (onCleanup) => {
  const query = this.searchQuery();
  let active = true;

  onCleanup(() => { active = false; }); // Manually tracking lifecycle

  const result = await this.api.search(query); 
  if (active) {
    this.results.set(result); // Writing to signal inside effect
  }
}); // requiring { allowSignalWrites: true }

This re-introduces the very race conditions and imperative state management that RxJS solved years ago.

The Root Cause: Synchronous Reactivity vs. Asynchronous Orchestration

The architectural paralysis stems from a misunderstanding of what Signals and Observables represent conceptually.

  1. Signals are for State (Synchronous): Signals represent a value at a specific point in time. They are pull-based and excellent for derived state (e.g., count * 2). They assume the value is available now.
  2. RxJS is for Events (Asynchronous): Observables represent a stream of events over time. They are push-based and possess the declarative operators necessary to manipulate time (debouncethrottledelay) and topology (switchMapmergeMapconcatMap).

The problem arises when you try to force an asynchronous event stream (network request) into a synchronous state primitive (Signal) without an orchestration layer. Signals have no concept of "cancelling" a pending Promise because Promises themselves are not cancelable by standard.

The Solution: The Hybrid Reactive Architecture

The robust architectural pattern for modern Angular is Reactive Orchestration, Signal Storage.

We utilize RxJS to manage the volatility of user events and network requests (the "dirty" work of timing and race conditions), and we use Signals to store the resulting stable data for the view.

The Pattern

  1. Input: Signals (User actions, Route params).
  2. Orchestration: RxJS (Debouncing, Switching, Error Handling).
  3. Output: Read-only Signal (View consumption).

Implementation

Here is a complete, thread-safe implementation of a searchable data grid using the toObservable and toSignal interoperability functions.

import { Component, Injectable, computed, inject, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { catchError, debounceTime, filter, map, of, switchMap, tap } from 'rxjs';

// 1. Define a robust state interface
interface SearchState<T> {
  data: T[];
  loading: boolean;
  error: string | null;
}

@Injectable({ providedIn: 'root' })
class ProductService {
  private http = inject(HttpClient);
  
  search(term: string) {
    // Simulating an API that might return out-of-order if not handled
    return this.http.get<any[]>(`/api/products?q=${term}`);
  }
}

@Component({
  selector: 'app-product-search',
  standalone: true,
  template: `
    <div class="search-container">
      <!-- Two-way binding to the source signal -->
      <input 
        [value]="searchTerm()" 
        (input)="updateSearch($event)" 
        placeholder="Search products..."
      />
    </div>

    @if (state().loading) {
      <div class="loader">Loading...</div>
    }

    @if (state().error) {
      <div class="error">{{ state().error }}</div>
    }

    <ul>
      @for (product of state().data; track product.id) {
        <li>{{ product.name }} - {{ product.price | currency }}</li>
      }
    </ul>
    
    <!-- Derived signal usage -->
    <footer>Found {{ count() }} items</footer>
  `
})
export class ProductSearchComponent {
  private service = inject(ProductService);

  // SOURCE: The Source of Truth
  // This is the trigger for the entire pipeline.
  readonly searchTerm = signal<string>('');

  // ORCHESTRATION: The RxJS Pipeline
  // We convert the signal to a stream to utilize switchMap capabilities.
  private searchStream$ = toObservable(this.searchTerm).pipe(
    filter(term => term.length > 2), // Only search if term > 2 chars
    debounceTime(300),               // Prevent API spam
    
    // The "Switch" logic handles the race condition.
    // If a new term arrives, the previous HTTP request is aborted efficiently.
    switchMap(term => 
      this.service.search(term).pipe(
        map(data => ({ data, loading: false, error: null })),
        catchError(err => of({ data: [], loading: false, error: 'Failed to load results' })),
        // Start with loading state per-request
        startWithState({ data: [], loading: true, error: null }) 
      )
    )
  );

  // SINK: The Read-Only Signal
  // Convert back to signal for optimal template rendering (glitch-free).
  // requireSync: false usually implies undefined initially, so we provide initialValue.
  readonly state = toSignal(this.searchStream$, { 
    initialValue: { data: [], loading: false, error: null } 
  });

  // COMPUTED: Derived State
  // Purely synchronous calculations based on the async result
  readonly count = computed(() => this.state().data.length);

  updateSearch(e: Event) {
    this.searchTerm.set((e.target as HTMLInputElement).value);
  }
}

// Helper operator for ergonomic loading states in streams
import { Observable, startWith } from 'rxjs';

function startWithState<T>(state: T) {
  return (source: Observable<T>) => source.pipe(startWith(state));
}

Why This Architecture Works

1. Handling the Race Condition (switchMap)

The switchMap operator is the critical component here. In the effect based anti-pattern, if the user types "Appl", the API fires. If they immediately type "Apple", a second API fires. If "Appl" returns after "Apple" due to network latency, the view displays the result of "Appl" while the input box says "Apple".

By using toObservable and switchMap, the subscription to the "Appl" request is torn down immediately when "Apple" is emitted. This ensures the browser cancels the XHR request and the callback never fires.

2. Template Performance (toSignal)

Angular's Change Detection for Signals is significantly more efficient than the AsyncPipe. By using toSignal, we unwrap the Observable into a reactive primitive. This allows us to use standard Signal features like computed to derive the count efficiently without creating secondary subscriptions or pipe logic in the template.

3. Separation of Concerns

This architecture creates a clear boundary:

  • Imperative Shell (Input): searchTerm.set(...)
  • Declarative Logic (Stream): searchStream$ handles how the data is fetched (debouncing, error handling, cancellation).
  • Reactive View (Output): state() serves as a pure dependency for the UI.

Conclusion

Do not discard RxJS. Its value proposition is not reactivity (which Signals handle better); its value proposition is complex event orchestration.

The architecture of modern Angular applications relies on the interoperability layer. Use Signals for what is happening now, and use RxJS to manage the complexity of changing from one state to another over time. If you need cancellation, buffering, or time-based filtering, you need RxJS. If you need granular updates to the DOM, you need Signals. Combining them via toObservable and toSignal is the technically correct approach.