skip to content

How would you build a Flutter movie search with RxDart so keystrokes are debounced and results from stale queries never reach the screen?

level: middleimportance: must knowfreq 58%

answer

  1. wait for a pause in typing
  2. skip repeats after the pause
  3. latest query wins
  4. the future still runs
  5. errors mapped inside the inner stream

basics

~20 s

Feed TextField changes into a stream, apply debounceTime so a query fires only after typing pauses, drop repeats with distinct, then switchMap each query to the search call so a newer query unsubscribes from the older one and stale results are discarded.

solid answer

~40 s

I push each `onChanged` value into a stream, then build `queries.map((q) => q.trim()).debounceTime(const Duration(milliseconds: 300)).distinct().switchMap(search)`. `debounceTime` emits a value only after 300 ms without another keystroke, `distinct` stops a re-search when the user types and deletes back to the same term, and `switchMap` subscribes to the newest search and cancels its subscription to the previous one, so an old, slow response can never overwrite newer results. I map errors inside the inner stream — for example with `onErrorReturnWith` into an error state — so one failed request does not end up as an error on the whole results stream. One caveat: `switchMap` cancels the subscription, not the HTTP request; the future still completes and is ignored unless the client supports cancellation.

code

dart · 14 lines
dart
import 'package:rxdart/rxdart.dart';

Stream<List<String>> titles(
  Stream<String> queries,
  Future<List<String>> Function(String) searchMovies,
) =>
    queries
        .map((q) => q.trim())
        .debounceTime(const Duration(milliseconds: 300))
        .distinct()
        .switchMap((q) => q.isEmpty
            ? Stream.value(const <String>[])
            : Stream.fromFuture(searchMovies(q))
                .onErrorReturnWith((_, __) => const <String>[]));

go deeper

for a junior

Recall the three steps — debounceTime, distinct, switchMap — and what each one prevents: too many requests, duplicate requests, stale results.

for a middle

Explain why debouncing alone does not fix out-of-order responses, why distinct goes after the debounce, and how errors are mapped inside the inner stream.

for a senior

Point out that switchMap discards stale results but cannot abort an eager Future, and say how you would wire real request cancellation and dispose the pipeline with the screen.

for a principal

Weigh a hand-built rxdart pipeline against the team's state library, such as bloc event transformers, so search behaviour is expressed once and tested the same way everywhere.

## The problem a search stream solves A movie-database app has a search box. Typing "dune" produces four `onChanged` calls — "d", "du", "dun", "dune" — and firing a request for each wastes the network and, worse, lets responses arrive **out of order**: if the "dun" request is slow, its results can land after "dune" and overwrite them. Dart's core `Stream` has no time-based or cancel-previous operator, which is exactly the gap `rxdart` fills. ## The pipeline, step by step 1. **Source.** Push every `TextField.onChanged` value into a stream (a subject or a `StreamController` the screen's controller owns). 2. **Normalise.** `map((q) => q.trim())` — a core `Stream` method. 3. **`debounceTime(const Duration(milliseconds: 300))`** — from rxdart. Each new value restarts a timer; a value is emitted only when the timer finishes without another value arriving. Four fast keystrokes become one query. 4. **`distinct()`** — core Dart. After debouncing, it drops a query equal to the previous one (the user typed "dune ", then deleted the space). 5. **`switchMap(search)`** — from rxdart. Each query is mapped to an inner stream of results; when a newer query arrives, `switchMap` cancels its subscription to the previous inner stream and listens to the new one. Only the latest query's results flow downstream. The order matters: `distinct` after `debounceTime` compares settled queries, while `distinct` before it would compare every keystroke. ## What switchMap does and does not cancel - It cancels the **subscription** to the old inner stream, so late results are never emitted. - It does **not** abort the work behind a `Future`. `Stream.fromFuture(api.search(q))` starts the request as soon as the mapper runs, because Dart futures are eager; cancelling the stream only means nobody listens to the answer. - Since rxdart **0.28.0**, `switchMap` pauses the outer stream while it waits for the old inner subscription's `cancel()` to complete, and forwards any error that cancellation throws. If the HTTP client supports cancellation, wire it into the inner stream's cancel path; otherwise the stale request simply finishes unobserved. ## Errors belong inside the inner stream If the search call throws, the error travels down the results stream. A Dart stream can carry an error and keep going, but the UI then has to treat the stream itself as "in error". The cleaner shape maps each outcome to a state inside `switchMap`: - `Stream.fromFuture(api.search(q)).map<SearchState>(SearchResults.new)` for success; - `.onErrorReturnWith((e, st) => SearchFailed(e))` from rxdart for failure; - an empty query short-circuits to `Stream.value(const SearchIdle())` without calling the API. ## Operator choices, compared | Step | Choice | Why not the alternative | |---|---|---| | Rate limit | `debounceTime` | `throttleTime` defaults to the first value of a window, so it would search for "d" and drop "dune" | | Flattening | `switchMap` | `flatMap` keeps every request alive and interleaves results; `exhaustMap` ignores new queries while one is in flight | | De-duplication | core `distinct` | no rxdart operator needed | ## Lifecycle The screen's controller owns the source and the subscription to the results stream; when the screen goes away, cancel the subscription and close the source so no timer or request keeps it alive. ```dart import 'package:rxdart/rxdart.dart'; sealed class SearchState { const SearchState(); } class SearchIdle extends SearchState { const SearchIdle(); } class SearchResults extends SearchState { const SearchResults(this.titles); final List<String> titles; } class SearchFailed extends SearchState { const SearchFailed(this.error); final Object error; } Stream<SearchState> movieSearch( Stream<String> queries, Future<List<String>> Function(String) searchMovies, ) { return queries .map((q) => q.trim()) .debounceTime(const Duration(milliseconds: 300)) .distinct() .switchMap((q) => q.isEmpty ? Stream<SearchState>.value(const SearchIdle()) : Stream.fromFuture(searchMovies(q)) .map<SearchState>(SearchResults.new) .onErrorReturnWith((e, _) => SearchFailed(e))); } ``` In an interview, name the three roles — **debounce the input, de-duplicate it, switch to the latest request** — and the caveat that switching discards stale results rather than stopping stale work.

  • Why put distinct after debounceTime rather than before it?
    Before debouncing, `distinct` compares individual keystrokes, which are almost always different, so it removes nothing useful. After debouncing it compares settled queries, so typing "dune", adding a space and deleting it within the window, or retyping the same term, does not fire a second identical search.
  • The user types quickly and the previous request is still running. Does switchMap stop that HTTP request?
    No. `switchMap` cancels its subscription to the inner stream, so the old results are never emitted, but a `Future` in Dart is eager and cannot be cancelled; the request finishes and its result is dropped. Stopping the request itself needs a client that supports cancellation, wired into the inner stream's cancel path.
  • What happens to the results stream if one search throws and you do not handle it inside switchMap?
    The error is emitted on the results stream. The stream keeps running for later queries, but consumers see an error event and must handle it, and listeners created with `cancelOnError: true` stop. Mapping the failure to a state inside the inner stream keeps the output a clean stream of states.

It is like a waiter who waits until you stop changing your mind before sending the order to the kitchen, and who bins any dish from an order you already replaced instead of bringing it to the table — though the kitchen may still have cooked it.

saying these in an interview costs you the question

  • switchMap aborts the HTTP request of the previous query.
  • flatMap is fine for search because only the last response matters.
  • throttleTime and debounceTime are interchangeable for a search box.
  • Debouncing alone prevents stale results from overwriting newer ones.
  • Call the search API directly from onChanged and ignore responses that look old.