Skip to content

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?

Code for this question
results$ = this.search.valueChanges.pipe(  debounceTime(300),  switchMap((q) => this.http.get<Candidate[]>('/api/candidates', { params: { q } })),);
Pick one

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

A pipeline is a stream of values. 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?

Code for this question
members$ = this.search.valueChanges.pipe(  debounceTime(300),  switchMap((q) => this.http.get<string[]>('/api/members', { params: { q } })),);
Pick one

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?

Code for this question
events$ = this.search.valueChanges.pipe(  switchMap((q) => this.http.get<EventSummary[]>('/api/events', { params: { q } })),);
Pick one

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?

Code for this question
batches$ = this.auths$.pipe(bufferTime(1000));
Pick one

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?

Code for this question
episodes$ = this.search.valueChanges.pipe(  debounceTime(300),  switchMap((q) => this.http.get<string[]>('/api/episodes', { params: { q } })),);
Pick one

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?

Code for this question
channels$ = this.search.valueChanges.pipe(  debounceTime(200),  switchMap((q) => this.http.get<string[]>('/api/channels', { params: { q } })),);
Pick one

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?

Code for this question
results$ = this.search.valueChanges.pipe(  debounceTime(300),  switchMap((q) => this.http.get<Video[]>('/api/videos', { params: { q } })),);
Pick one

Go deeper

Pro tip
  1. Before porting a search, check that your queryFn reads signal. Destructure it in the arguments and pass it to fetch; then a superseded search is aborted instead of running on into the cache. When: translating a switchMap search (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, switchMap stops 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 the Observable with an RxJS combinator like switchMap, 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 the AbortSignal, 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 the params computation changes while the resource is loading." "httpResource is a wrapper around HttpClient that gives you the request status and response as signals." (step 9)

Checked against the tools and docs as of 2026-10-12.