RxJS streams vs query caches and effects
Request-shaped pipelines become a debounced query key with a passed-on signal; event-shaped pipelines stay in RxJS, subscribed once from an effect.
Predict first
Before you read: predict
ExamHall's Angular proctor console searches candidates by name:
A proctor types a, al and ali at 0, 100 and 200 ms, stops to read, then types alic at 700 ms. The server takes 800 ms to answer each search.
How many search requests are sent, and how many of them does Angular cancel?
results$ = this.search.valueChanges.pipe( debounceTime(300), switchMap((q) => this.http.get<Candidate[]>('/api/candidates', { params: { q } })),);The 30-second version
RxJS treats keystrokes as a stream: debounceTime waits for quiet, and switchMap unsubscribes the previous request, which HttpClient aborts. In React, put a debounced value in the query key; TanStack Query aborts the superseded request only if queryFn passes its signal on, and it caches by key. Pushed events stay a stream: keep RxJS for them.
Request-shaped pipelines become a debounced query key with a passed-on signal; event-shaped pipelines stay in RxJS, subscribed once from an effect.
How it works
Step 1 of 12
valueChanges emits the box's text on every key; each operator in .pipe() reshapes that flow over time.Analogy: An optician's eye test and the lens lab
A customer reads letters while the optometrist flips lenses, and the shop sends each prescription to an outside lab to grind.
| At the optician | In the code |
|---|---|
| he keeps saying "this one… no, that one"; she writes the prescription only once he has stopped changing his mind for a few seconds | debounceTime (step 2) |
| she phones the lab with it; when he changes his mind again, she rings to stop the first grind before ordering the new one | switchMap unsubscribes, HttpClient aborts (steps 3–4) |
| a colleague who never rings back gets the first pair anyway and files it in the drawer under its prescription | no signal: the request runs on and is cached (step 7) |
| a customer back the same afternoon with the same prescription gets the pair from the drawer at once | a fresh cached key (step 8) |
Breaks down when: the lab stops grinding when she rings; a server keeps working after an abort (js-abort-cancellation step 5). Wrong inference: "an aborted search never reached the server".
Contrasting case: the lab's edging machine sends a status line every few seconds whether anyone asks or not, and the manager reads a one-minute summary: nobody orders it, so it is a stream (bufferTime, steps 10–12).
In real terms: request-shaped work (one question, one answer) becomes a debounced query key with the signal passed on; event-shaped work stays a subscription.
Examples, run for real
4.1 ExamHall: EXH-3590 and MR !3890
The React port of the console's candidate search (EXH-3590) became a chain of effects: one copies the text into q after 300 ms, the next fetches q and stores the reply. A proctor types ka, waits, then adds t; the server is slow on short names. MR !3890 puts the debounced value in a query key and passes the signal (steps 5–7):
// EXH-3590, the port
useEffect(() => { const id = setTimeout(() => setQ(text), 300); return () => clearTimeout(id); }, [text]);
useEffect(() => { if (q) fetch(`/api/candidates?q=${q}`).then((r) => r.json()).then(setResults); }, [q]);
// MR !3890
const q = useDebouncedValue(text, 300);
const { data: results = [] } = useQuery({
queryKey: ['candidates', q],
queryFn: ({ signal }) => fetch(`/api/candidates?q=${q}`, { signal }).then((r) => r.json()),
enabled: q !== '',
});
4.1 keys k@0 ka@100 kat@700 · server: ka 1200 ms, kat 300 ms
4.1 EXH-3590 port (two effects, no abort): requests ka, kat · aborted none · the list shows kat, then ka
4.1 MR !3890 (debounced key, signal passed): requests ka, kat · aborted ka · the list shows kat
The port lets the slow ka reply land last and shows the wrong list; the query key keeps one search on screen, and the passed signal aborts the other.
4.2 Chat: a typing indicator stays a stream
A chat client's socket pushes events; the indicator keeps typing events and shows who typed in each half-second (steps 10–12). React subscribes once:
useEffect(() => {
const sub = events$.pipe(
filter((e) => e.type === 'typing'),
bufferTime(500),
map((batch) => [...new Set(batch.map((e) => e.user))]),
).subscribe(setTyping);
return () => sub.unsubscribe();
}, []);
4.2 14 socket events over 1.4 s (10 typing, 4 messages) · subscriptions while mounted 1 · batches handed to React 3 · commits 3: ann, bo typing | cy, bo, ann typing | cy, ann, bo typing
4.2 after unmount: subscriptions 0 · timers the indicator left 0
One subscription carries the whole burst; the cleanup closes it, so no event reaches an unmounted component.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A chat app's member search. The Angular version:
The React port:
function useDebouncedValue(value, ms) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
function MemberSearch() {
const [text, setText] = useState('');
const q = useDebouncedValue(text, 300);
const { data = [] } = useQuery({
queryKey: ['members', q],
queryFn: () => fetch(`/api/members?q=${q}`).then((r) => r.json()),
enabled: q !== '',
});
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ul>{data.map((m) => <li key={m}>{m}</li>)}</ul>
</>
);
}
In both, a user types s, sa and sam at 0, 100 and 200 ms, then pastes samir at 600 ms. The server takes 700 ms to answer each search.
Which version aborts the sam request?
members$ = this.search.valueChanges.pipe( debounceTime(300), switchMap((q) => this.http.get<string[]>('/api/members', { params: { q } })),);Question 2 of 8
A ticket site's event search; picking a recent search fills the box at once. The Angular version:
The React port:
function EventSearch() {
const [q, setQ] = useState('');
const { data = [] } = useQuery({
queryKey: ['events', q],
queryFn: ({ signal }) => fetch(`/api/events?q=${q}`, { signal }).then((r) => r.json()),
staleTime: 30_000,
enabled: q !== '',
});
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<ul>{data.map((ev) => <li key={ev}>{ev}</li>)}</ul>
</>
);
}
In both, a fan picks rock, jazz and rock again from her recent searches, at 0, 5 and 10 seconds. The server answers each search in 400 ms.
How many search requests does each version send?
events$ = this.search.valueChanges.pipe( switchMap((q) => this.http.get<EventSummary[]>('/api/events', { params: { q } })),);Question 3 of 8
A payments ledger's risk panel shows card authorisations as they arrive over a socket. The Angular version:
The React port gets the list received so far as a prop:
function useDebouncedValue(value, ms) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
function RiskPanel({ auths }) {
const shown = useDebouncedValue(auths, 1000);
return <p>{shown.length} authorisations</p>;
}
Authorisations arrive every 200 ms, from 100 ms to 2900 ms.
Which panel shows new authorisations before 2500 ms?
batches$ = this.auths$.pipe(bufferTime(1000));Question 4 of 8
Port t1's candidate search to React from a blank file: a useDebouncedValue(value, ms) hook and a CandidateSearch component with an input and a list, so that typing waits 300 ms of quiet, a superseded request is aborted, and an empty box sends nothing.
Save it as one .jsx file with no imports (the check supplies React's hooks, useQuery and fetch).
Question 5 of 8
An interviewer says: "Your chat client's Angular code has a member search (debounceTime, switchMap, HTTP) and a typing indicator fed by the socket (filter, bufferTime). Tell me whether you would keep RxJS in the React version".
Answer in three sentences.
Question 6 of 8
A podcast app's episode search. The Angular version:
The React port:
function useDebouncedValue(value, ms) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
function EpisodeSearch() {
const [text, setText] = useState('');
const q = useDebouncedValue(text, 300);
const { data = [] } = useQuery({
queryKey: ['episodes', q],
queryFn: ({ signal }) => fetch(`/api/episodes?q=${q}`, { signal }).then((r) => r.json()),
enabled: q !== '',
});
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ul>{data.map((ep) => <li key={ep}>{ep}</li>)}</ul>
</>
);
}
In both, a listener pastes bebop at 0 ms, reads the list, then clears the box at 1500 ms and leaves it empty. The server answers each search in 200 ms.
After she clears the box, how many requests for the empty search does each version send?
episodes$ = this.search.valueChanges.pipe( debounceTime(300), switchMap((q) => this.http.get<string[]>('/api/episodes', { params: { q } })),);Question 7 of 8
A chat app's Angular channel search:
A user types de at 0 ms and, after a pause, pastes design at 500 ms. The server answers de in 900 ms and design in 200 ms.
Which result lists does channels$ emit, in order?
channels$ = this.search.valueChanges.pipe( debounceTime(200), switchMap((q) => this.http.get<string[]>('/api/channels', { params: { q } })),);Question 8 of 8
A video app's catalogue search. The Angular version:
The React port:
function useDebouncedValue(value, ms) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
function VideoSearch() {
const [text, setText] = useState('');
const q = useDebouncedValue(text, 300);
const { data = [] } = useQuery({
queryKey: ['videos', q],
queryFn: ({ signal }) => fetch(`/api/videos?q=${q}`, { signal }).then((r) => r.json()),
enabled: q !== '',
});
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ul>{data.map((v) => <li key={v}>{v}</li>)}</ul>
</>
);
}
In both, a viewer types jazz (keys at 0, 100, 200 and 300 ms) and reads the results. At 2000 ms she types a fifth z by mistake and deletes it at 2100 ms. The server answers each search in 200 ms.
How many search requests does each version send?
results$ = this.search.valueChanges.pipe( debounceTime(300), switchMap((q) => this.http.get<Video[]>('/api/videos', { params: { q } })),);Go deeper
Pro tip
- Before porting a search, check that your
queryFnreadssignal. Destructure it in the arguments and pass it tofetch; then a superseded search is aborted instead of running on into the cache. When: translating aswitchMapsearch (step 7). Status: RAN (React 19.3.0, TanStack Query 5.104.0, jsdom 30.1.1).
Sources
They support the teaching; no key is cited. Valid as of 2026-10-12 (rxjs 7.8, Angular 22.2, TanStack Query 5.104).
- rxjs.dev, switchMap: https://rxjs.dev/api/operators/switchMap (
rxjs-switchmap.txt). Quoted: "When a new inner Observable is emitted,switchMapstops emitting items from the earlier-emitted inner Observable and begins emitting items from the new one." (step 3) - rxjs.dev, debounceTime: https://rxjs.dev/api/operators/debounceTime (
rxjs-debouncetime.txt), not named by the curriculum row; teaching support only. Quoted: "Emits a notification from the source Observable only after a particular time span has passed without another source emission." (step 2) - rxjs.dev, bufferTime: https://rxjs.dev/api/operators/bufferTime (
rxjs-buffertime.txt), not named by the curriculum row; teaching support only. Quoted: "Collects values from the past as an array, and emits those arrays periodically in time." (step 10) - angular.dev, Making HTTP requests: https://angular.dev/guide/http/making-requests (
angular-http-making-requests.txt), not named by the curriculum row; teaching support only. Quoted: "Once subscribed, unsubscribing will abort the in-progress request." "if you use theObservablewith an RxJS combinator likeswitchMap, this cancellation will clean up any stale requests." (step 4) - tanstack.com, Query Cancellation: https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation (
tq-query-cancellation.txt). Quoted: "By default, queries that unmount or become unused before their promises are resolved are not cancelled. This means that after the promise has resolved, the resulting data will be available in the cache." "However, if you consume theAbortSignal, the Promise will be cancelled (e.g. aborting the fetch)" (step 7) - angular.dev, Async reactivity with resources: https://angular.dev/guide/signals/resource (
angular-resource.txt). Quoted: "A resource aborts an outstanding loading operation if theparamscomputation changes while the resource is loading." "httpResourceis a wrapper aroundHttpClientthat gives you the request status and response as signals." (step 9)
Related lessons
Checked against the tools and docs as of 2026-10-12.