skip to content

With package:bloc, how would you build a debounced city search whose stale responses can never overwrite newer results?

level: middleimportance: should knowfreq 52%

answer

  1. EventTransformer is just a function
  2. (events, mapper) returns a stream
  3. debounce first, then switchMap
  4. cancelled runs cannot emit
  5. empty query short-circuits

basics

~20 s

Give the query-changed handler a custom EventTransformer that debounces the event stream and then switch-maps it through the mapper, such as events.debounce(300 ms).switchMap(mapper) with stream_transform. The debounce cuts requests; switchMap cancels older runs so their late emits are ignored.

solid answer

~40 s

An `EventTransformer<E>` is a function `(Stream<E> events, EventMapper<E> mapper) => Stream<E>`: bloc hands it the handler's events and a `mapper` that runs the handler for one event. For search, compose `events.debounce(const Duration(milliseconds: 300)).switchMap(mapper)` from stream_transform — the shape the bloc repository's own search example uses — or `restartable<E>().call(events.debounce(d), mapper)` with bloc_concurrency. The debounce waits for typing to pause, so 'L', 'Li', 'Lis' become one request. `switchMap` cancels the previous handler run when a newer one starts; bloc cancels that run's `Emitter`, so a slow 'Li' response emits into nothing. Neither half suffices alone: debounce still lets two runs overlap after a mid-word pause, and restartable alone fires a request per keystroke. In the handler, emit the initial state for an empty query and check `emit.isDone` before costly post-processing.

code

dart · 33 lines
dart
import 'package:bloc/bloc.dart';
import 'package:stream_transform/stream_transform.dart';

EventTransformer<E> debounceRestartable<E>(Duration duration) {
  return (events, mapper) => events.debounce(duration).switchMap(mapper);
}

class CitySearchBloc extends Bloc<CitySearchEvent, CitySearchState> {
  CitySearchBloc(this._cities) : super(CitySearchInitial()) {
    on<CitySearchQueryChanged>(
      _onQueryChanged,
      transformer: debounceRestartable(const Duration(milliseconds: 300)),
    );
  }

  final CityRepository _cities;

  Future<void> _onQueryChanged(
    CitySearchQueryChanged event,
    Emitter<CitySearchState> emit,
  ) async {
    final query = event.query.trim();
    if (query.isEmpty) return emit(CitySearchInitial());
    emit(CitySearchInProgress());
    try {
      final cities = await _cities.search(query);
      if (emit.isDone) return; // a newer query took over
      emit(CitySearchSuccess(cities));
    } catch (_) {
      emit(CitySearchFailure('Could not load cities'));
    }
  }
}

go deeper

for a junior

Recall that on<E> accepts a transformer, and that search needs both a debounce and a way to ignore stale results.

for a middle

Explain the EventTransformer signature, how debounce and switchMap compose, and what a cancelled Emitter does to late emits.

for a senior

Show you know the limits: cancellation discards state updates but not network work, why droppable is wrong here, and where emit.isDone saves effort.

for a principal

Standardise a small set of shared transformers for the codebase so timing policy is consistent and reviewed once, not reinvented per feature.

## The problem A travel app's city search sends a request as the user types. With a plain `on<CitySearchQueryChanged>` handler in **bloc** 9.2: 1. Every keystroke adds an event, and every event starts a request — 'L', 'Li', 'Lis', 'Lisb'. 2. The default transformer runs these handlers **concurrently**. 3. Responses come back in network order, not typing order. If 'Li' is slow, its results arrive after 'Lisb' and overwrite them. Two separate fixes are needed: fewer requests (**debounce**) and no stale writes (**cancel the older run**). ## Anatomy of an EventTransformer `on<E>` takes an optional `transformer` of this type: ```dart typedef EventTransformer<Event> = Stream<Event> Function( Stream<Event> events, EventMapper<Event> mapper, ); ``` - `events` is the stream of `E` events added to the Bloc. - `mapper` turns one event into a stream representing **one run of your handler**; subscribing starts the run, cancelling that subscription cancels the run's Emitter. - What you return decides timing: which events reach `mapper`, and how runs are combined. The default is effectively `events.map(mapper)` flattened concurrently. bloc_concurrency's transformers are one-liners over the same signature, which is why they compose with other stream operators. ## Composing debounce and switchMap ```dart EventTransformer<E> debounceRestartable<E>(Duration duration) { return (events, mapper) => events.debounce(duration).switchMap(mapper); } // in the constructor on<CitySearchQueryChanged>( _onQueryChanged, transformer: debounceRestartable(const Duration(milliseconds: 300)), ); ``` - `debounce` (from stream_transform) forwards an event only once no newer one has arrived for the duration, so a burst of keystrokes collapses into its final query. - `switchMap` subscribes to the newest run and cancels the previous — the same behaviour as bloc_concurrency's `restartable()`. - Equivalent with bloc_concurrency: `(events, mapper) => restartable<E>().call(events.debounce(duration), mapper)`, mirroring the repository's `throttleDroppable` example for infinite lists. ## What cancellation does and does not do - The cancelled run's Emitter is marked done: later `emit` calls are **ignored**, and `emit.isDone` returns `true`. - The Dart code is **not** stopped: `await _cities.search('Li')` still completes and the handler resumes. Check `emit.isDone` before expensive work such as ranking results. - The network request is **not** aborted. If that matters, the handler must pass its own cancellation signal to the HTTP client. ## Alternatives compared | Transformer | Requests while typing | Stale overwrite possible? | |---|---|---| | default (concurrent) | one per keystroke | yes | | debounce + concurrent | one per pause | yes, if a pause splits a word | | `restartable()` only | one per keystroke | no | | debounce + switchMap | one per pause | no | | debounce + `droppable()` | one per pause | no, but keeps the **oldest** query | `droppable()` is the trap here: it ignores the newer query while an older one is running, so the user sees results for 'Li' after typing 'Lisbon'. ## Handler details that still matter - Trim the query and emit `CitySearchInitial()` for an empty string instead of calling the API. - Emit `CitySearchInProgress()` before awaiting so the UI shows progress. - Catch repository errors and emit a failure state; an uncaught error goes to `onError` and is rethrown. - Choose the debounce duration from how people type; the bloc examples use 300 milliseconds.

  • Is the emit.isDone check required for correctness after a restartable cancellation?
    No: a cancelled Emitter already ignores emit, so the stale result cannot reach the state. The check is an optimisation and a clarity aid — it skips work such as sorting or mapping results that nobody will see, and documents that the run may have been superseded.
  • Why not debounce in the widget with a Timer instead?
    It works, but the timing rule then lives in UI code, must be cancelled in dispose, and is invisible to the Bloc's tests. A transformer keeps the behaviour next to the handler it governs and is tested by adding events to the Bloc.

saying these in an interview costs you the question

  • Debounce alone guarantees results arrive in typing order
  • droppable is the right transformer for search as you type
  • switchMap aborts the HTTP request of the older query
  • An EventTransformer receives the current state and returns a state
  • The handler must cancel the older run itself