Angular / Intermediate / Lesson 10
RxJS and signal interoperability
Choose between value state and event streams, make cancellation and error placement explicit, and create signal subscriptions at a managed ownership boundary.
On this page
Lesson goals
- Explain observable subscription and higher-order request cancellation.
- Use toSignal with an initial value and injection ownership.
- Test time-dependent behavior without real network calls.
A current value and an event history are different abstractions
A signal provides synchronous access to its current value, while an observable describes a sequence observed through subscription. Neither is a universal replacement for the other. RxJS is useful for temporal relationships such as debouncing, cancellation, retries, and combining event sources. Signals are useful for local state and pure derived values read by templates. Keep the ownership of subscriptions explicit rather than converting repeatedly at arbitrary getters. A cold stream can run its producer for each subscription. Decide whether consumers need independent work, shared results, or a retained latest value before selecting a sharing operator or bridge.
Verify cancellation with virtual time
The complete RxJS test uses virtual cold streams, not timers, HTTP, or a browser. A new outer value arrives before the first inner result, so switchMap unsubscribes the previous inner stream and only the later result appears. The expected marble string specifies both the value and completion positions. The assertion compares the actual scheduled notifications with the expected sequence and prints success only when they match. This proves the chosen stream behavior in the synthetic schedule. It does not prove a server rolled back a canceled mutation or that a real network honors the same timing.
import { switchMap } from "rxjs";
import { TestScheduler } from "rxjs/testing";
const scheduler = new TestScheduler((actual, expected) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error("schedule mismatch"); }
});
scheduler.run(({ cold, expectObservable }) => {
const queries = cold("a-b----|", { a: "first", b: "second" });
const result = queries.pipe(switchMap(query => cold("---x|", { x: query })));
expectObservable(result).toBe("-----b-|", { b: "second" });
});
console.log("1 schedule passed");
1 schedule passed
Only second is emitted in the tested schedule, and the success line appears after the notification assertion passes.
Create the bridge once under its owner
toSignal subscribes to an observable and exposes its latest value through a signal. Provide an initial value when the source has not yet emitted, or use requireSync only when immediate emission is a real tested contract. By default the bridge needs an injection context or an explicit injector so its subscription can be cleaned up with that owner. The complete example establishes a local context and reads a synchronous finite source. It prints three because of emits one, two, and three before the read. Destroying the injector ends the ownership boundary. No component rendering is exercised.
import { Injector, runInInjectionContext } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { of } from "rxjs";
const owner = Injector.create({ providers: [] });
const latest = runInInjectionContext(owner, () => toSignal(of(1, 2, 3), { initialValue: 0 }));
console.log(latest());
owner.destroy();
3
The latest synchronous value is three. This checks bridge ownership and reading, not an Angular view or asynchronous HTTP response.
Choose where errors end a stream
A thrown observable error can terminate its subscription; catching inside a request branch can turn one failed request into a recoverable state while keeping the outer input stream alive. A toSignal read can surface a source error, so model expected failures before bridging rather than surprising a template with an uncontrolled exception. Use takeUntilDestroyed with the intended DestroyRef for subscriptions tied to a component, especially when calling it outside an implicit injection context. Do not create a fresh toSignal inside a repeatedly evaluated getter. Repeated subscriptions can duplicate work, and cleanup does not undo already committed external effects.
Test values, timing, and cleanup separately
Use synchronous streams for simple mapping checks and virtual time for race or cancellation behavior. Component tests additionally verify loading and error presentation after render synchronization. Test a source that emits late, a source that fails, repeated user input, and owner destruction. Avoid treating shareReplay as an automatic permanent cache: its configuration and source lifetime determine retention and resubscription behavior. Keep the feature contract small and observable. A stream operator choice should follow whether the application wants latest-only work, ordered work, ignored overlaps, or concurrent work, not a blanket rule that every request must use the same operator.
Common errors and fixes
A getter repeatedly creates toSignal subscriptions.
- Why it happens
- Bridge creation was treated as a harmless value read.
- How to fix it
- Create one owned bridge during supported initialization.
- Prevent it next time
- Assert producer subscription counts when work is expensive.
A failed search prevents later searches.
- Why it happens
- The outer stream terminated at an unintended error boundary.
- How to fix it
- Model per-request failure at the correct inner boundary.
- Prevent it next time
- Test an error followed by another valid input.
Cancellation is described as remote rollback.
- Why it happens
- Unsubscription was confused with server transaction control.
- How to fix it
- Define external operation identity and reconciliation separately.
- Prevent it next time
- Keep local stream and server guarantees distinct.
Check your understanding
1. Move the second outer value later so the first inner result has already emitted. What changes conceptually, and how should the test be updated?
Show worked solution
The first result can now legitimately appear before the switch, because switchMap cannot erase a value already delivered. Update the virtual schedule and expected notification sequence to include the first emission at its actual frame. This distinguishes canceling unfinished work from rewriting history. The expected result should follow the declared timeline rather than an unconditional latest-only slogan.
2. Replace of(1,2,3) with a source that emits later. Explain why initialValue or an undefined-aware view becomes necessary.
Show worked solution
Before the first emission there is no source value to read. An initial value supplies a documented temporary state, while an undefined-aware type forces the consumer to handle absence. requireSync would be a false promise unless the source truly emits immediately and the contract is tested. Add a delayed-source test and verify the loading presentation separately.
Key takeaways
- Use streams for temporal relationships and signals for current state.
- Own every bridge and subscription.
- Test timing with controlled schedules, not real services.