Advanced Signals, Computed State & RxJS Interop
While basic Signals provide intuitive state tracking, building large-scale enterprise applications requires a deep understanding of advanced Signal mechanics. This includes constructing complex derived dependency graphs, configuring custom equality functions to prevent unnecessary downstream re-computations, executing untracked reads, and bridging seamlessly between Angular Signals and RxJS streaming pipelines.
┌─────────────────────────────────────────────────────────────┐
│ Angular Signal & RxJS Interoperability │
│ │
│ RxJS Stream (Push / Async) Angular Signal (Pull) │
│ ┌───────────────────────────┐ ┌─────────────────────┐ │
│ │ websocket$.pipe(...) │ ───> │ toSignal(source$) │ │
│ │ (Events, Time, Streaming) │ │ (Synchronous Read) │ │
│ └───────────────────────────┘ └─────────────────────┘ │
│ ▲ │ │
│ │ │ │
│ ┌───────────────────────────┐ │ │
│ │ toObservable(mySignal) │ <───────────────┘ │
│ │ (Debounce, SwitchMap) │ │
│ └───────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
1. Custom Equality Functions in Signals
By default, Angular Signals compare values using JavaScript's Object.is() (reference equality). If a signal receives a new object with identical content, all downstream computed() signals and template bindings will re-evaluate. You can pass a custom equal comparator to prevent unnecessary propagation:
import { signal, computed } from '@angular/core';
export interface Coordinates {
x: number;
y: number;
}
// Custom equality comparator: Only emit if coordinate values actually change
const areCoordsEqual = (a: Coordinates, b: Coordinates): boolean =>
a.x === b.x && a.y === b.y;
export class MapController {
readonly position = signal<Coordinates>({ x: 0, y: 0 }, { equal: areCoordsEqual });
updatePosition(newX: number, newY: number): void {
// If coordinates match previous values, downstream computeds WILL NOT run
this.position.set({ x: newX, y: newY });
}
}
2. Untracked Reads: untracked()
Inside a computed() signal or an effect(), you may want to read the current value of a signal without subscribing to its changes. The untracked() utility executes a read without registering a dependency in the reactive graph:
import { Component, signal, effect, untracked } from '@angular/core';
@Component({
selector: 'app-analytics-logger',
standalone: true,
template: `<button (click)="actionCount.update(c => c + 1)">Click</button>`
})
export class AnalyticsLoggerComponent {
readonly actionCount = signal(0);
readonly currentUserId = signal('user_123');
constructor() {
effect(() => {
// We want this effect to trigger ONLY when actionCount changes
const count = this.actionCount();
// Read currentUserId WITHOUT tracking it as a reactive dependency
const userId = untracked(() => this.currentUserId());
console.log(`[Telemetry] User ${userId} triggered action #${count}`);
});
}
}
3. RxJS Interoperability: toSignal()
The toSignal() utility converts any RxJS Observable<T> into a Signal<T>. It automatically manages the underlying subscription, subscribing when instantiated and unsubscribing when the enclosing injection context (component or service) is destroyed:
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';
@Component({
selector: 'app-stock-ticker',
standalone: true,
template: `
<h3>Live Price: \${{ price() }}</h3>
`
})
export class StockTickerComponent {
private http = inject(HttpClient);
// Convert timer observable to signal with initial fallback value
readonly price = toSignal(
interval(1000).pipe(map(() => (100 + Math.random() * 10).toFixed(2))),
{ initialValue: '100.00' }
);
}
Options in toSignal:
initialValue: Value returned before the Observable emits its first item.requireSync: Iftrue, enforces that the Observable emits synchronously upon subscription; otherwise throws a runtime error.rejectErrors: Iftrue, propagates errors to Angular'sErrorHandlerrather than rethrowing inside the signal.
4. Converting Signals to Observables: toObservable()
When you need to perform complex asynchronous operations like debouncing user input or cancellation, convert a Signal into an Observable using toObservable():
import { Component, signal, inject } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-live-search',
standalone: true,
template: `
<input (input)="query.set($any($event.target).value)" placeholder="Search..." />
<ul>
@for (item of searchResults(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
`
})
export class LiveSearchComponent {
private http = inject(HttpClient);
readonly query = signal<string>('');
// 1. Convert signal to observable
private readonly searchResults$ = toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term => this.http.get<any[]>(`/api/search?q=${encodeURIComponent(term)}`))
);
// 2. Convert result stream back to signal for clean template consumption
readonly searchResults = toSignal(this.searchResults$, { initialValue: [] });
}
Summary & Key Takeaways
- Custom equality functions (
equal) prevent unnecessary signal re-evaluations for complex objects and arrays. untracked(() => signal())allows reading signal values without establishing a reactive subscription inside computeds or effects.toSignal(observable$)seamlessly converts RxJS streams to Angular Signals with automatic unsubscription.toObservable(signal)allows applying RxJS operators (debounceTime,switchMap) to reactive signal changes.
Best Practices & Senior Guidance
- Use Signals for State, RxJS for Async Pipelines: Do not choose one exclusively. Use Signals for component/UI state and RxJS for debouncing, WebSockets, and asynchronous coordination.
- Always Provide
initialValueintoSignal: Supplying aninitialValueprevents your signal type from becomingSignal<T | undefined>. - Keep
computed()Free of Side Effects: Acomputed()function must be a pure mathematical transformation of inputs to outputs. Never call API endpoints or mutate external state insidecomputed().