Skip to content

RxJS Interop with Signals

@angular/core/rxjs-interop provides the four APIs that connect both worlds:

APIDirectionPurpose
toSignal()Observable → SignalConsume a stream as state
toObservable()Signal → ObservableFeed a signal into an RxJS pipeline
takeUntilDestroyed()—Automatic unsubscription
rxResource()Observable → ResourceAsync data with loading/error state

import { toSignal } from '@angular/core/rxjs-interop';
export class UserComponent {
private readonly api = inject(UserApi);
readonly user = toSignal(this.api.currentUser$, { initialValue: null });
readonly displayName = computed(() => this.user()?.name ?? 'Guest');
}
// 1. Explicit initial value — the signal type is User | null
toSignal(source$, { initialValue: null });
// 2. Undefined until the first emission — type is User | undefined
toSignal(source$);
// 3. Requires a synchronous first emission — type is User
// Throws at runtime if the source does not emit immediately
toSignal(source$, { requireSync: true });

By default, a source error is rethrown the next time the signal is read, which usually crashes the template. Handle errors in the pipeline:

readonly user = toSignal(
this.api.currentUser$.pipe(catchError(() => of(null))),
{ initialValue: null },
);

toSignal() subscribes immediately and unsubscribes when the injection context is destroyed. That means:

  • Called in a field initializer of a component → tied to the component.
  • Called in a providedIn: 'root' service → lives for the whole app.
// Outside an injection context you must pass an injector
toSignal(source$, { injector: this.injector, initialValue: null });

Converts a signal into an observable so you can apply time-based operators.

import { toObservable } from '@angular/core/rxjs-interop';
export class SearchComponent {
readonly term = signal('');
readonly results = toSignal(
toObservable(this.term).pipe(
debounceTime(300),
distinctUntilChanged(),
filter((t) => t.length >= 2),
switchMap((t) => this.api.search(t).pipe(catchError(() => of([])))),
),
{ initialValue: [] },
);
}

This is the canonical typeahead in modern Angular: signal in, signal out, RxJS only for the timing in between.


Section titled “rxResource() — the recommended default for data fetching”
import { rxResource } from '@angular/core/rxjs-interop';
export class ProductListComponent {
private readonly api = inject(ProductApi);
readonly categoryId = signal<string | undefined>(undefined);
readonly products = rxResource({
params: () => {
const id = this.categoryId();
return id ? { id } : undefined; // undefined ⇒ request is skipped
},
stream: ({ params }) => this.api.listByCategory(params.id),
});
}

Template:

@if (products.isLoading()) {
<app-spinner />
} @else if (products.error(); as error) {
<app-error [error]="error" (retry)="products.reload()" />
} @else {
@for (product of products.value() ?? []; track product.id) {
<app-product-card [product]="product" />
}
}
  • Automatic request cancellation when params change.
  • isLoading(), error(), value(), status() as signals.
  • reload() to refetch.
  • set() / update() for optimistic local updates.
  • Skipping the request when params returns undefined.
readonly user = httpResource<User>(() => `/api/users/${this.userId()}`);

No service, no pipeline — appropriate for straightforward reads.


You do not need a big-bang rewrite. Migrate by layer, outside-in:

Replace | async with signals. Zero risk, immediate readability gain.

// Before
readonly users$ = this.api.getUsers();
// After
readonly users = toSignal(this.api.getUsers(), { initialValue: [] });

Replace combineLatest + map chains that have no time dimension with computed().

// Before
readonly visible$ = combineLatest([this.users$, this.filter$]).pipe(
map(([users, filter]) => users.filter((u) => u.role === filter)),
);
// After
readonly visible = computed(() =>
this.users().filter((u) => u.role === this.filter()),
);

Replace trigger-subject + switchMap facades with rxResource.

Keep RxJS for:

  • Debounce, throttle, retry with backoff.
  • WebSockets, SSE, polling.
  • Router events, form valueChanges.
  • Anything where cancellation semantics matter.

// ❌ Pointless round trip
readonly total = toSignal(
toObservable(this.items).pipe(map((i) => i.length)),
{ initialValue: 0 },
);
// ✅
readonly total = computed(() => this.items().length);

Using effect() to copy one signal into another

Section titled “Using effect() to copy one signal into another”
// ❌ Creates a second source of truth and risks loops
effect(() => this.filtered.set(this.items().filter(Boolean)));
// ✅
readonly filtered = computed(() => this.items().filter(Boolean));
// ❌ Leaks a subscription on every run
effect(() => {
this.api.load(this.id()).subscribe((d) => this.data.set(d));
});
// ✅
readonly data = rxResource({
params: () => ({ id: this.id() }),
stream: ({ params }) => this.api.load(params.id),
});

it('should expose the latest emitted user as a signal', () => {
const subject = new BehaviorSubject<User | null>(null);
TestBed.configureTestingModule({
providers: [{ provide: UserApi, useValue: { currentUser$: subject } }],
});
const component = TestBed.createComponent(UserComponent).componentInstance;
expect(component.displayName()).toBe('Guest');
subject.next({ id: '1', name: 'Ada' });
TestBed.tick(); // flush the effect
expect(component.displayName()).toBe('Ada');
});